Micron Document
🎖️GitЯра🎖️

Commit b79ec33b80907add5a1b40d62ec556d519389fc7


Parents : f450645
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T06:23:13-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T13:23:13Z

feat(settings): warn before enabling licensed (ham) mode (#6632)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 5bcab3b31b..bb8bc67094 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -874,8 +874,13 @@ led_heartbeat
led_state
legacy_admin_channel
library_count
+### LICENSED ###
licensed_amateur_radio
licensed_amateur_radio_text
+licensed_mode_enable_confirm
+licensed_mode_enable_title
+licensed_mode_enable_warning
+licensed_mode_enable_warning_signed
### LOAD ###
load
load_15_min

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index aad22422a2..bd0fb922b0 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -901,8 +901,13 @@
<string name="led_state">LED state</string>
<string name="legacy_admin_channel">Legacy Admin channel</string>
<string name="library_count">%1$d libraries</string>
+ <!-- LICENSED -->
<string name="licensed_amateur_radio">Licensed amateur radio (Ham)</string>
<string name="licensed_amateur_radio_text">Enabling this option disables encryption and is not compatible with the default Meshtastic network.</string>
+ <string name="licensed_mode_enable_confirm">Enable licensed mode</string>
+ <string name="licensed_mode_enable_title">Enable licensed (Ham) mode?</string>
+ <string name="licensed_mode_enable_warning">Licensed mode removes channel encryption keys and disables the admin channel, so all traffic is sent as plaintext that anyone can read — and this firmware cannot sign it, so other nodes cannot verify it came from you. This is not compatible with the default Meshtastic network, and you remain responsible for meeting your amateur radio license requirements and local regulations.</string>
+ <string name="licensed_mode_enable_warning_signed">Licensed mode removes channel encryption keys and disables the admin channel, so all traffic is sent as plaintext that anyone can read — but it is digitally signed, letting other nodes verify it came from you. Your node number may change once to match your identity key, so favorites, message history, remote admin, and other nodes may initially treat it as new; you remain responsible for meeting your amateur radio license requirements and local regulations.</string>
<!-- LOAD -->
<string name="load">Load</string>
<string name="load_15_min">Load 15m</string>

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt
new file mode 100644
index 0000000000..df2a046e7f
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.radio.component
+
+import androidx.compose.material3.CardDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.licensed_amateur_radio
+import org.meshtastic.core.resources.licensed_amateur_radio_text
+import org.meshtastic.core.resources.licensed_mode_enable_confirm
+import org.meshtastic.core.resources.licensed_mode_enable_title
+import org.meshtastic.core.resources.licensed_mode_enable_warning
+import org.meshtastic.core.resources.licensed_mode_enable_warning_signed
+import org.meshtastic.core.ui.component.MeshtasticResourceDialog
+import org.meshtastic.core.ui.component.SwitchPreference
+
+internal const val LICENSED_MODE_SWITCH_TEST_TAG = "licensed_mode_switch"
+
+/**
+ * The licensed (ham) mode toggle. Enabling is staged behind a confirmation dialog: on firmware that signs licensed
+ * traffic (design#122, `has_xeddsa`), the copy explains authenticated-but-plaintext operation and the one-time node
+ * number migration; otherwise it carries the legacy plaintext/compatibility warning. Disabling applies immediately.
+ */
+@Composable
+internal fun LicensedModeSetting(
+ checked: Boolean,
+ enabled: Boolean,
+ signingSupported: Boolean?,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ var showEnableConfirmation by rememberSaveable { mutableStateOf(false) }
+
+ LaunchedEffect(enabled) {
+ if (!enabled) {
+ showEnableConfirmation = false
+ }
+ }
+
+ if (showEnableConfirmation) {
+ MeshtasticResourceDialog(
+ titleRes = Res.string.licensed_mode_enable_title,
+ messageRes =
+ if (signingSupported == true) {
+ Res.string.licensed_mode_enable_warning_signed
+ } else {
+ Res.string.licensed_mode_enable_warning
+ },
+ confirmTextRes = Res.string.licensed_mode_enable_confirm,
+ onConfirm = {
+ showEnableConfirmation = false
+ if (enabled) {
+ onCheckedChange(true)
+ }
+ },
+ onDismiss = { showEnableConfirmation = false },
+ )
+ }
+
+ SwitchPreference(
+ title = stringResource(Res.string.licensed_amateur_radio),
+ summary = stringResource(Res.string.licensed_amateur_radio_text),
+ checked = checked,
+ enabled = enabled,
+ modifier = Modifier.testTag(LICENSED_MODE_SWITCH_TEST_TAG),
+ onCheckedChange = { licensed ->
+ if (licensed && !checked) {
+ showEnableConfirmation = true
+ } else {
+ onCheckedChange(licensed)
+ }
+ },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
index d9a3843815..26553ef905 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
@@ -34,8 +34,6 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.call_sign
import org.meshtastic.core.resources.call_sign_summary
import org.meshtastic.core.resources.hardware_model
-import org.meshtastic.core.resources.licensed_amateur_radio
-import org.meshtastic.core.resources.licensed_amateur_radio_text
import org.meshtastic.core.resources.long_name
import org.meshtastic.core.resources.node_id
import org.meshtastic.core.resources.short_name
@@ -127,11 +125,10 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
- SwitchPreference(
- title = stringResource(Res.string.licensed_amateur_radio),
- summary = stringResource(Res.string.licensed_amateur_radio_text),
+ LicensedModeSetting(
checked = formState.value.is_licensed,
enabled = state.connected,
+ signingSupported = state.metadata?.has_xeddsa,
onCheckedChange = { licensed ->
val longName = formState.value.long_name
// The field becomes the callsign: clear an over-long name so the user enters one.
@@ -142,7 +139,6 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
long_name = if (clearForCallsign) "" else longName,
)
},
- containerColor = CardDefaults.cardColors().containerColor,
)
}
}

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt
new file mode 100644
index 0000000000..f77e0e287b
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.radio.component
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.cancel
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.licensed_mode_enable_confirm
+import org.meshtastic.core.resources.licensed_mode_enable_warning
+import org.meshtastic.core.resources.licensed_mode_enable_warning_signed
+import org.meshtastic.core.ui.theme.AppTheme
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@OptIn(ExperimentalTestApi::class)
+class LicensedModeSettingTest {
+
+ @Test
+ fun `enabling licensed mode requires confirmation`() = runComposeUiTest {
+ var checked: Boolean? = null
+ setContent {
+ AppTheme {
+ LicensedModeSetting(
+ checked = false,
+ enabled = true,
+ signingSupported = true,
+ onCheckedChange = { checked = it },
+ )
+ }
+ }
+
+ onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick()
+
+ assertNull(checked)
+ onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.cancel)).performClick()
+ assertNull(checked)
+ onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertDoesNotExist()
+ }
+
+ @Test
+ fun `confirmed enable applies the change`() = runComposeUiTest {
+ var checked: Boolean? = null
+ setContent {
+ AppTheme {
+ LicensedModeSetting(
+ checked = false,
+ enabled = true,
+ signingSupported = true,
+ onCheckedChange = { checked = it },
+ )
+ }
+ }
+
+ onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick()
+ onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).performClick()
+
+ assertEquals(true, checked)
+ }
+
+ @Test
+ fun `signing firmware shows authenticated plaintext and migration copy`() = runComposeUiTest {
+ setContent { AppTheme { LicensedModeSetting(checked = false, enabled = true, signingSupported = true) {} } }
+
+ onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick()
+
+ onNodeWithText(getString(Res.string.licensed_mode_enable_warning_signed)).assertIsDisplayed()
+ }
+
+ @Test
+ fun `non-signing firmware shows legacy plaintext copy`() = runComposeUiTest {
+ setContent { AppTheme { LicensedModeSetting(checked = false, enabled = true, signingSupported = false) {} } }
+
+ onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick()
+
+ onNodeWithText(getString(Res.string.licensed_mode_enable_warning)).assertIsDisplayed()
+ }
+
+ @Test
+ fun `unknown capability shows legacy plaintext copy`() = runComposeUiTest {
+ setContent { AppTheme { LicensedModeSetting(checked = false, enabled = true, signingSupported = null) {} } }
+
+ onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick()
+
+ onNodeWithText(getString(Res.string.licensed_mode_enable_warning)).assertIsDisplayed()
+ }
+
+ @Test
+ fun `disabling licensed mode applies immediately without confirmation`() = runComposeUiTest {
+ var checked: Boolean? = null
+ setContent {
+ AppTheme {
+ LicensedModeSetting(
+ checked = true,
+ enabled = true,
+ signingSupported = true,
+ onCheckedChange = { checked = it },
+ )
+ }
+ }
+
+ onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick()
+
+ assertEquals(false, checked)
+ onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertDoesNotExist()
+ }
+
+ @Test
+ fun `connection loss dismisses confirmation without changing state`() = runComposeUiTest {
+ var enabled by mutableStateOf(true)
+ var checked: Boolean? = null
+ setContent {
+ AppTheme {
+ LicensedModeSetting(
+ checked = false,
+ enabled = enabled,
+ signingSupported = true,
+ onCheckedChange = { checked = it },
+ )
+ }
+ }
+
+ onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick()
+ onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertIsDisplayed()
+
+ enabled = false
+ waitForIdle()
+
+ onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertDoesNotExist()
+ assertNull(checked)
+ }
+}

Served by rngit 1.5.0 - Generated in 0.15s